Polski

Odkryj podstawy binarnych drzew poszukiwań (BST) i naucz się, jak je efektywnie implementować w JavaScript. Ten przewodnik omawia strukturę BST, operacje i praktyczne przykłady dla programistów na całym świecie.

Binarne drzewa poszukiwań: Kompleksowy przewodnik implementacji w JavaScript

Binarne drzewa poszukiwań (BST) to fundamentalna struktura danych w informatyce, szeroko stosowana do efektywnego wyszukiwania, sortowania i odzyskiwania danych. Ich hierarchiczna struktura pozwala na uzyskanie logarytmicznej złożoności czasowej w wielu operacjach, co czyni je potężnym narzędziem do zarządzania dużymi zbiorami danych. Ten przewodnik stanowi kompleksowy przegląd BST i demonstruje ich implementację w JavaScript, z myślą o programistach na całym świecie.

Zrozumienie binarnych drzew poszukiwań

Czym jest binarne drzewo poszukiwań?

Binarne drzewo poszukiwań to drzewiasta struktura danych, w której każdy węzeł ma co najwyżej dwoje dzieci, nazywanych lewym dzieckiem i prawym dzieckiem. Kluczową właściwością BST jest to, że dla każdego danego węzła:

Ta właściwość zapewnia, że elementy w BST są zawsze uporządkowane, co umożliwia efektywne wyszukiwanie i odzyskiwanie danych.

Kluczowe pojęcia

Implementacja binarnego drzewa poszukiwań w JavaScript

Definiowanie klasy Node

Najpierw definiujemy klasę Node, która będzie reprezentować każdy węzeł w BST. Każdy węzeł będzie zawierał key do przechowywania danych oraz wskaźniki left i right na swoje dzieci.


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

Definiowanie klasy BinarySearchTree

Następnie definiujemy klasę BinarySearchTree. Ta klasa będzie zawierać węzeł korzenia oraz metody do wstawiania, wyszukiwania, usuwania i przechodzenia po drzewie.


class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  // Methods will be added here
}

Wstawianie

Metoda insert dodaje nowy węzeł z podanym kluczem do BST. Proces wstawiania zachowuje właściwość BST, umieszczając nowy węzeł w odpowiedniej pozycji względem istniejących węzłów.


insert(key) {
  const newNode = new Node(key);

  if (this.root === null) {
    this.root = newNode;
  } else {
    this.insertNode(this.root, newNode);
  }
}

insertNode(node, newNode) {
  if (newNode.key < node.key) {
    if (node.left === null) {
      node.left = newNode;
    } else {
      this.insertNode(node.left, newNode);
    }
  } else {
    if (node.right === null) {
      node.right = newNode;
    } else {
      this.insertNode(node.right, newNode);
    }
  }
}

Przykład: Wstawianie wartości do BST


const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

Wyszukiwanie

Metoda search sprawdza, czy w BST istnieje węzeł z podanym kluczem. Przechodzi przez drzewo, porównując klucz z kluczem bieżącego węzła i odpowiednio przechodząc do lewego lub prawego poddrzewa.


search(key) {
  return this.searchNode(this.root, key);
}

searchNode(node, key) {
  if (node === null) {
    return false;
  }

  if (key < node.key) {
    return this.searchNode(node.left, key);
  } else if (key > node.key) {
    return this.searchNode(node.right, key);
  } else {
    return true;
  }
}

Przykład: Wyszukiwanie wartości w BST


console.log(bst.search(9));  // Output: true
console.log(bst.search(2));  // Output: false

Usuwanie

Metoda remove usuwa węzeł z podanym kluczem z BST. Jest to najbardziej złożona operacja, ponieważ musi zachować właściwość BST podczas usuwania węzła. Należy rozważyć trzy przypadki:


remove(key) {
  this.root = this.removeNode(this.root, key);
}

removeNode(node, key) {
  if (node === null) {
    return null;
  }

  if (key < node.key) {
    node.left = this.removeNode(node.left, key);
    return node;
  } else if (key > node.key) {
    node.right = this.removeNode(node.right, key);
    return node;
  } else {
    // key is equal to node.key

    // case 1 - a leaf node
    if (node.left === null && node.right === null) {
      node = null;
      return node;
    }

    // case 2 - node has only 1 child
    if (node.left === null) {
      node = node.right;
      return node;
    } else if (node.right === null) {
      node = node.left;
      return node;
    }

    // case 3 - node has 2 children
    const aux = this.findMinNode(node.right);
    node.key = aux.key;
    node.right = this.removeNode(node.right, aux.key);
    return node;
  }
}

findMinNode(node) {
  let current = node;
  while (current != null && current.left != null) {
    current = current.left;
  }
  return current;
}

Przykład: Usuwanie wartości z BST


bst.remove(7);
console.log(bst.search(7)); // Output: false

Przechodzenie po drzewie

Przechodzenie po drzewie polega na odwiedzaniu każdego węzła w określonej kolejności. Istnieje kilka popularnych metod przechodzenia:


inOrderTraverse(callback) {
  this.inOrderTraverseNode(this.root, callback);
}

inOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.inOrderTraverseNode(node.left, callback);
    callback(node.key);
    this.inOrderTraverseNode(node.right, callback);
  }
}

preOrderTraverse(callback) {
  this.preOrderTraverseNode(this.root, callback);
}

preOrderTraverseNode(node, callback) {
  if (node !== null) {
    callback(node.key);
    this.preOrderTraverseNode(node.left, callback);
    this.preOrderTraverseNode(node.right, callback);
  }
}

postOrderTraverse(callback) {
  this.postOrderTraverseNode(this.root, callback);
}

postOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.postOrderTraverseNode(node.left, callback);
    this.postOrderTraverseNode(node.right, callback);
    callback(node.key);
  }
}

Przykład: Przechodzenie po BST


const printNode = (value) => console.log(value);

bst.inOrderTraverse(printNode);   // Output: 3 5 8 9 10 11 12 13 14 15 18 20 25
bst.preOrderTraverse(printNode);  // Output: 11 5 3 9 8 10 15 13 12 14 20 18 25
bst.postOrderTraverse(printNode); // Output: 3 8 10 9 12 14 13 18 25 20 15 11

Wartości minimalne i maksymalne

Znajdowanie minimalnych i maksymalnych wartości w BST jest proste dzięki jego uporządkowanej naturze.


min() {
  return this.minNode(this.root);
}

minNode(node) {
  let current = node;
  while (current !== null && current.left !== null) {
    current = current.left;
  }
  return current;
}

max() {
  return this.maxNode(this.root);
}

maxNode(node) {
  let current = node;
  while (current !== null && current.right !== null) {
    current = current.right;
  }
  return current;
}

Przykład: Znajdowanie wartości minimalnych i maksymalnych


console.log(bst.min().key); // Output: 3
console.log(bst.max().key); // Output: 25

Praktyczne zastosowania binarnych drzew poszukiwań

Binarne drzewa poszukiwań są używane w różnorodnych zastosowaniach, w tym:

Kwestie wydajności

Wydajność BST zależy od jego struktury. W najlepszym przypadku, zrównoważone BST pozwala na logarytmiczną złożoność czasową dla operacji wstawiania, wyszukiwania i usuwania. Jednak w najgorszym przypadku (np. drzewo zdegenerowane), złożoność czasowa może spaść do czasu liniowego.

Drzewa zrównoważone a niezrównoważone

Zrównoważone BST to takie, w którym wysokość lewego i prawego poddrzewa każdego węzła różni się co najwyżej o jeden. Algorytmy samorównoważące, takie jak drzewa AVL i drzewa czerwono-czarne, zapewniają, że drzewo pozostaje zrównoważone, co gwarantuje stałą wydajność. Różne regiony mogą wymagać różnych poziomów optymalizacji w zależności od obciążenia serwera; równoważenie pomaga utrzymać wydajność przy wysokim globalnym użyciu.

Złożoność czasowa

Zaawansowane koncepcje BST

Drzewa samorównoważące

Drzewa samorównoważące to BST, które automatycznie dostosowują swoją strukturę, aby utrzymać równowagę. Zapewnia to, że wysokość drzewa pozostaje logarytmiczna, co gwarantuje stałą wydajność dla wszystkich operacji. Popularne drzewa samorównoważące to drzewa AVL i drzewa czerwono-czarne.

Drzewa AVL

Drzewa AVL utrzymują równowagę, zapewniając, że różnica wysokości między lewym a prawym poddrzewem dowolnego węzła wynosi co najwyżej jeden. Gdy ta równowaga zostanie zaburzona, wykonuje się rotacje w celu jej przywrócenia.

Drzewa czerwono-czarne

Drzewa czerwono-czarne używają właściwości kolorów (czerwony lub czarny) do utrzymania równowagi. Są bardziej złożone niż drzewa AVL, ale oferują lepszą wydajność w niektórych scenariuszach.

Przykład kodu JavaScript: Kompletna implementacja binarnego drzewa poszukiwań


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  insert(key) {
    const newNode = new Node(key);

    if (this.root === null) {
      this.root = newNode;
    } else {
      this.insertNode(this.root, newNode);
    }
  }

  insertNode(node, newNode) {
    if (newNode.key < node.key) {
      if (node.left === null) {
        node.left = newNode;
      } else {
        this.insertNode(node.left, newNode);
      }
    } else {
      if (node.right === null) {
        node.right = newNode;
      } else {
        this.insertNode(node.right, newNode);
      }
    }
  }

  search(key) {
    return this.searchNode(this.root, key);
  }

  searchNode(node, key) {
    if (node === null) {
      return false;
    }

    if (key < node.key) {
      return this.searchNode(node.left, key);
    } else if (key > node.key) {
      return this.searchNode(node.right, key);
    } else {
      return true;
    }
  }

  remove(key) {
    this.root = this.removeNode(this.root, key);
  }

  removeNode(node, key) {
    if (node === null) {
      return null;
    }

    if (key < node.key) {
      node.left = this.removeNode(node.left, key);
      return node;
    } else if (key > node.key) {
      node.right = this.removeNode(node.right, key);
      return node;
    } else {
      // key is equal to node.key

      // case 1 - a leaf node
      if (node.left === null && node.right === null) {
        node = null;
        return node;
      }

      // case 2 - node has only 1 child
      if (node.left === null) {
        node = node.right;
        return node;
      } else if (node.right === null) {
        node = node.left;
        return node;
      }

      // case 3 - node has 2 children
      const aux = this.findMinNode(node.right);
      node.key = aux.key;
      node.right = this.removeNode(node.right, aux.key);
      return node;
    }
  }

  findMinNode(node) {
    let current = node;
    while (current != null && current.left != null) {
      current = current.left;
    }
    return current;
  }

  min() {
    return this.minNode(this.root);
  }

  minNode(node) {
    let current = node;
    while (current !== null && current.left !== null) {
      current = current.left;
    }
    return current;
  }

  max() {
    return this.maxNode(this.root);
  }

  maxNode(node) {
    let current = node;
    while (current !== null && current.right !== null) {
      current = current.right;
    }
    return current;
  }

  inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback);
  }

  inOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.inOrderTraverseNode(node.left, callback);
      callback(node.key);
      this.inOrderTraverseNode(node.right, callback);
    }
  }

  preOrderTraverse(callback) {
    this.preOrderTraverseNode(this.root, callback);
  }

  preOrderTraverseNode(node, callback) {
    if (node !== null) {
      callback(node.key);
      this.preOrderTraverseNode(node.left, callback);
      this.preOrderTraverseNode(node.right, callback);
    }
  }

  postOrderTraverse(callback) {
    this.postOrderTraverseNode(this.root, callback);
  }

  postOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.postOrderTraverseNode(node.left, callback);
      this.postOrderTraverseNode(node.right, callback);
      callback(node.key);
    }
  }
}

// Przykład użycia
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

const printNode = (value) => console.log(value);

console.log("Przechodzenie in-order:");
bst.inOrderTraverse(printNode);

console.log("Przechodzenie pre-order:");
bst.preOrderTraverse(printNode);

console.log("Przechodzenie post-order:");
bst.postOrderTraverse(printNode);

console.log("Wartość minimalna:", bst.min().key);
console.log("Wartość maksymalna:", bst.max().key);

console.log("Wyszukaj 9:", bst.search(9));
console.log("Wyszukaj 2:", bst.search(2));

bst.remove(7);
console.log("Wyszukaj 7 po usunięciu:", bst.search(7));

Podsumowanie

Binarne drzewa poszukiwań to potężna i wszechstronna struktura danych o licznych zastosowaniach. Ten przewodnik dostarczył kompleksowego przeglądu BST, obejmując ich strukturę, operacje i implementację w JavaScript. Dzięki zrozumieniu zasad i technik omówionych w tym przewodniku, programiści na całym świecie mogą efektywnie wykorzystywać BST do rozwiązywania szerokiego zakresu problemów w tworzeniu oprogramowania. Od zarządzania globalnymi bazami danych po optymalizację algorytmów wyszukiwania, znajomość BST jest nieocenionym atutem dla każdego programisty.

Kontynuując swoją podróż w świecie informatyki, zgłębianie zaawansowanych koncepcji, takich jak drzewa samorównoważące i ich różne implementacje, jeszcze bardziej poszerzy Twoje zrozumienie i możliwości. Ćwicz i eksperymentuj z różnymi scenariuszami, aby opanować sztukę efektywnego wykorzystywania binarnych drzew poszukiwań.